跳到主要内容

第四章:完善 Health Tracker——记录运动、体重和断食

上一章,我们已经使用 Python、MCP SDK 2.x 和 SQLite,实现了饮食记录功能。

现在,我们可以通过 MCP Inspector 调用 record_food,将每天吃的食物保存到数据库中,还可以查询某一天的饮食记录和热量摄入情况。

但是,开发到这里,我发现一个问题:

如果我的目标是健康减脂,仅仅记录每天吃了多少热量,真的够吗?

显然不够。

例如,今天我摄入了 1800 千卡,但同时进行了 40 分钟快走。明天我可能没有运动,却只摄入了 1500 千卡。

如果只看饮食热量,很难完整了解自己的生活习惯。

除此之外,我还希望知道:

  • 最近一周体重有没有下降?
  • 今天做了哪些运动?
  • 昨天有没有完成断食计划?
  • 某一天的饮食、运动、体重和断食情况分别是什么?

所以,这一章我们继续完善 Health Tracker。

本章目标: 在已有饮食记录功能的基础上,增加运动、体重和断食记录,并开发一个综合查询工具。

4.1 先思考:这些数据应该保存在一张表里吗?​

上一章,我们创建了 food_records 表。

现在又要记录运动、体重和断食。

一种看似简单的方案是,继续给 food_records 增加字段。

例如:

food_records
├── food_name
├── calories
├── exercise_type
├── duration_minutes
├── weight_kg
├── fasting_start
└── fasting_end

但是,这样很快就会遇到问题。

记录香蕉时,运动类型、运动时长、体重和断食时间都没有意义。

记录体重时,食物名称和热量又没有意义。

最终,我们会得到一张包含大量空字段的表。

因此,我决定把不同类型的数据分开保存。

image-20261021155213862

这样,每张表只负责保存一种类型的数据。

未来增加新的健康指标,例如睡眠、饮水量,也可以采用相同的设计方式。

4.2 创建运动记录表​

首先,我们来开发运动记录功能。

我希望记录这样的信息:

今天快走了 40 分钟,估计消耗 180 千卡。

那么,一条运动记录至少需要包含运动日期、运动名称、运动时长和估算消耗热量。

设计 exercise_records 表​

字段类型说明
idINTEGER唯一编号
record_dateTEXT运动日期
exercise_typeTEXT运动类型
duration_minutesINTEGER运动时长,分钟
calories_burnedREAL估算消耗热量,kcal
notesTEXT备注

打开上一章创建的 db.py,找到 init_db() 函数。

在创建 food_records 表的 SQL 后面,继续增加:

conn.execute("""
CREATE TABLE IF NOT EXISTS exercise_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_date TEXT NOT NULL,
exercise_type TEXT NOT NULL,
duration_minutes INTEGER NOT NULL,
calories_burned REAL,
notes TEXT
)
""")

注意,这段代码要放在原来 with get_connection() as conn: 的代码块内。

CREATE TABLE IF NOT EXISTS 不会删除原来的饮食记录,也不会清空已有数据。

编写数据库插入函数​

继续在 db.py 中增加:

def insert_exercise(
record_date: str,
exercise_type: str,
duration_minutes: int,
calories_burned: float | None = None,
notes: str | None = None,
) -> int:
with get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO exercise_records (
record_date,
exercise_type,
duration_minutes,
calories_burned,
notes
)
VALUES (?, ?, ?, ?, ?)
""",
(
record_date,
exercise_type,
duration_minutes,
calories_burned,
notes,
),
)
return cursor.lastrowid

这段代码与上一章的 insert_food() 非常相似。

区别只是数据表和字段不同。

这里仍然使用 SQL 参数占位符,避免直接拼接用户输入。

注册 record_exercise 工具​

打开 main.py,增加导入:

from db import insert_exercise

然后添加 MCP 工具:

@mcp.tool()
def record_exercise(
record_date: str,
exercise_type: str,
duration_minutes: int,
calories_burned: float | None = None,
notes: str | None = None,
) -> dict:
"""记录一次运动,包括日期、类型、时长和估算消耗热量。"""

try:
valid_date(record_date)

if not exercise_type.strip():
raise ValueError("运动类型不能为空")

if duration_minutes <= 0:
raise ValueError("运动时长必须大于 0")

check_nonnegative(
calories_burned=calories_burned
)

record_id = insert_exercise(
record_date=record_date,
exercise_type=exercise_type.strip(),
duration_minutes=duration_minutes,
calories_burned=calories_burned,
notes=notes,
)

return {
"success": True,
"record_id": record_id,
"message": "运动记录保存成功",
}

except ValueError as exc:
return {
"success": False,
"error": str(exc),
}

这里用到了两个辅助函数:valid_date() 和 check_nonnegative()。

上一章我们已经写过日期和数值校验逻辑,现在多个工具都需要它们,所以没有必要每次复制相同的代码。

在 main.py 中添加:

from datetime import date
import math


def valid_date(value: str) -> None:
parsed = date.fromisoformat(value)

if parsed.isoformat() != value:
raise ValueError("日期格式必须为 YYYY-MM-DD")


def check_nonnegative(**values) -> None:
for name, value in values.items():
if value is None:
continue

if not math.isfinite(value) or value < 0:
raise ValueError(
f"{name} 必须是有限的非负数"
)

后续也可以把上一章 record_food() 和 get_food_records() 中重复的校验逻辑改为调用这些函数。

使用 Inspector 测试​

重新启动 MCP Server:

uv run python main.py

连接 MCP Inspector,刷新工具列表。

选择 record_exercise,输入:

{
"record_date": "2026-10-20",
"exercise_type": "快走",
"duration_minutes": 40,
"calories_burned": 180,
"notes": "运动消耗热量为估算值"
}

正常情况下,应该返回:

{
"success": true,
"record_id": 1,
"message": "运动记录保存成功"
}

现在,我们已经能够保存运动记录。

不过要注意:运动消耗热量与体重、运动强度和个人身体状况有关。这里的 180 千卡只是测试数据,不代表每个人快走 40 分钟都会消耗相同热量。

4.3 开发体重记录工具​

接下来,我们记录体重。

在减脂过程中,体重是一个很重要的观察指标。

但我不希望系统只保存一个“当前体重”。

因为我还想知道过去一周、一个月,甚至半年的变化趋势。

所以,每次称重都应该保存成一条独立记录。

创建 weight_records 表​

继续在 init_db() 中增加:

conn.execute("""
CREATE TABLE IF NOT EXISTS weight_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_date TEXT NOT NULL,
weight_kg REAL NOT NULL,
notes TEXT
)
""")

这里使用 weight_kg 保存公斤数。

如果用户输入的是中国常用的“斤”,后面可以让 ChatGPT 将其换算成公斤,再调用工具。

例如:

170 斤 = 85 kg

统一存储单位有一个重要好处:后续进行体重趋势分析时,不必再判断每条记录使用什么单位。

编写数据库函数​

在 db.py 中添加:

def insert_weight(
record_date: str,
weight_kg: float,
notes: str | None = None,
) -> int:
with get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO weight_records (
record_date,
weight_kg,
notes
)
VALUES (?, ?, ?)
""",
(record_date, weight_kg, notes),
)
return cursor.lastrowid

注册 record_weight 工具​

在 main.py 中导入:

from db import insert_weight

然后添加:

@mcp.tool()
def record_weight(
record_date: str,
weight_kg: float,
notes: str | None = None,
) -> dict:
"""记录指定日期的体重,单位为公斤 kg。"""

try:
valid_date(record_date)

if not math.isfinite(weight_kg) or weight_kg <= 0:
raise ValueError("体重必须是有限的正数")

record_id = insert_weight(
record_date=record_date,
weight_kg=weight_kg,
notes=notes,
)

return {
"success": True,
"record_id": record_id,
"message": "体重记录保存成功",
}

except ValueError as exc:
return {
"success": False,
"error": str(exc),
}

测试体重记录​

在 MCP Inspector 中调用:

{
"record_date": "2026-10-20",
"weight_kg": 85.1,
"notes": "早晨空腹称重"
}

如果保存成功,就说明我们已经能够记录体重。

这里还有一个值得思考的问题:

如果同一天称重两次,应该覆盖第一次,还是保存两条记录?

目前我们的设计会保存两条。

这是有意保留的灵活性。

因为早晨和晚上称重,数值可能不同。

以后如果要统计每天的体重趋势,可以规定使用早晨空腹体重,或者进一步增加称重时间字段。

本章先不增加这些复杂功能。

4.4 开发断食记录工具​

除了饮食、运动和体重,我还希望记录断食情况。

例如:

昨天晚上 6 点开始断食,今天早上 8 点结束。

这里有一个与前面不同的地方。

饮食和运动通常只需要记录某一天。

但断食可能跨越两天。

因此,仅仅保存一个日期是不够的,我们需要保存完整的开始和结束时间。

创建 fasting_records 表​

在 init_db() 中增加:

conn.execute("""
CREATE TABLE IF NOT EXISTS fasting_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
duration_hours REAL NOT NULL,
notes TEXT
)
""")

这里使用:

YYYY-MM-DDTHH:MM:SS+08:00

这样的 ISO 8601 格式保存带时区偏移的时间。

例如:

2026-10-19T18:00:00+08:00

这样可以避免未来服务器部署到其他地区后,因为时区不同导致断食时长计算错误。

编写数据库插入函数​

在 db.py 中添加:

def insert_fasting(
start_time: str,
end_time: str,
duration_hours: float,
notes: str | None = None,
) -> int:
with get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO fasting_records (
start_time,
end_time,
duration_hours,
notes
)
VALUES (?, ?, ?, ?)
""",
(
start_time,
end_time,
duration_hours,
notes,
),
)
return cursor.lastrowid

注册 record_fasting 工具​

在 main.py 中增加:

from datetime import datetime
from db import insert_fasting

然后添加:

@mcp.tool()
def record_fasting(
start_time: str,
end_time: str,
notes: str | None = None,
) -> dict:
"""
记录一次已经结束的断食。

start_time 和 end_time 必须是包含时区偏移的
ISO 8601 日期时间,例如:
2026-10-19T18:00:00+08:00
"""

try:
start = datetime.fromisoformat(start_time)
end = datetime.fromisoformat(end_time)

if start.utcoffset() is None or end.utcoffset() is None:
raise ValueError("开始和结束时间必须包含时区")

if end <= start:
raise ValueError("结束时间必须晚于开始时间")

duration_hours = round(
(end - start).total_seconds() / 3600,
2,
)

record_id = insert_fasting(
start_time=start.isoformat(),
end_time=end.isoformat(),
duration_hours=duration_hours,
notes=notes,
)

return {
"success": True,
"record_id": record_id,
"duration_hours": duration_hours,
"message": "断食记录保存成功",
}

except ValueError as exc:
return {
"success": False,
"error": str(exc),
}

注意,我们没有要求客户端传入断食时长。

因为开始和结束时间已经确定,Python 完全可以自己计算。

这体现了一个重要的接口设计原则:

能由程序准确计算的数据,就不要依赖客户端重复提供。

测试跨天断食​

在 MCP Inspector 中调用:

{
"start_time": "2026-10-19T18:00:00+08:00",
"end_time": "2026-10-20T08:00:00+08:00",
"notes": "晚餐后开始,次日早餐前结束"
}

返回结果类似:

{
"success": true,
"record_id": 1,
"duration_hours": 14.0,
"message": "断食记录保存成功"
}

这样,跨天断食也可以准确计算。

需要说明的是,这里只实现记录功能,并不意味着推荐每个人采用相同的断食时长。是否适合断食,应根据个人健康状况判断。

4.5 开发每日健康汇总工具​

到这里,我们已经能够保存四种数据:

  • 饮食
  • 运动
  • 体重
  • 断食

但如果每次查询都要分别调用四个工具,就显得有些麻烦。

例如,我想知道:

10 月 20 日,我的健康记录有哪些?

如果客户端分别调用饮食、运动、体重和断食查询工具,就会产生多次请求。

所以,我决定增加一个新的工具:

get_daily_summary(record_date)

它负责一次性返回指定日期的综合健康数据。

第一步:编写数据库查询函数​

打开 db.py,添加:

from datetime import date, datetime, time, timedelta, timezone


def select_daily_summary(record_date: str) -> dict:
with get_connection() as conn:
foods = conn.execute(
"""
SELECT *
FROM food_records
WHERE record_date = ?
ORDER BY id
""",
(record_date,),
).fetchall()

exercises = conn.execute(
"""
SELECT *
FROM exercise_records
WHERE record_date = ?
ORDER BY id
""",
(record_date,),
).fetchall()

weights = conn.execute(
"""
SELECT *
FROM weight_records
WHERE record_date = ?
ORDER BY id
""",
(record_date,),
).fetchall()

fastings = conn.execute(
"""
SELECT *
FROM fasting_records
ORDER BY id
"""
).fetchall()

# 断食记录可能跨越多天,因此根据时间区间判断
# 此处先按北京时间定义查询日期的起止边界。
tz = timezone(timedelta(hours=8))
day_start = datetime.combine(
date.fromisoformat(record_date),
time.min,
tzinfo=tz,
)
day_end = day_start + timedelta(days=1)

matched_fastings = []

for row in fastings:
start = datetime.fromisoformat(row["start_time"])
end = datetime.fromisoformat(row["end_time"])

if start < day_end and end > day_start:
matched_fastings.append(dict(row))

food_list = [dict(row) for row in foods]
exercise_list = [dict(row) for row in exercises]

known_foods = [
row["calories"]
for row in food_list
if row["calories"] is not None
]

known_exercises = [
row["calories_burned"]
for row in exercise_list
if row["calories_burned"] is not None
]

return {
"record_date": record_date,
"foods": food_list,
"exercises": exercise_list,
"weights": [dict(row) for row in weights],
"fastings": matched_fastings,
"summary": {
"food_count": len(food_list),
"exercise_count": len(exercise_list),
"total_calories": (
round(sum(known_foods), 2)
if known_foods else None
),
"total_exercise_calories": (
round(sum(known_exercises), 2)
if known_exercises else None
),
"food_calories_complete": (
len(known_foods) == len(food_list)
),
"exercise_calories_complete": (
len(known_exercises) == len(exercise_list)
),
},
}

这段代码虽然较长,但核心逻辑并不复杂。

首先,分别查询当天的饮食、运动和体重记录。

然后,查询与当天存在时间重叠的断食记录。

最后,计算已知的饮食热量和运动消耗热量。

这里有两个细节需要注意。

第一,断食记录可能跨天。

例如,10 月 19 日晚上开始,10 月 20 日早上结束。

因此,这条记录与 19 日和 20 日都有关。

本例为了便于理解,使用北京时间 UTC+08:00 定义查询日期边界。正式项目中,应将用户时区作为配置,而不是写死在代码里。

第二,饮食热量与运动消耗不能简单等同于人体的热量盈余。

因为人体还存在基础代谢、日常活动等能量消耗。

所以,本章只分别返回饮食摄入和运动消耗,不把两者的差值称为“热量缺口”。

第二步:注册 get_daily_summary​

打开 main.py,增加导入:

from db import select_daily_summary

然后添加:

@mcp.tool()
def get_daily_summary(record_date: str) -> dict:
"""
查询指定日期的综合健康记录。

包括饮食、运动、体重和与当天重叠的断食记录。
"""

try:
valid_date(record_date)

data = select_daily_summary(record_date)

return {
"success": True,
**data,
}

except ValueError as exc:
return {
"success": False,
"error": str(exc),
}

保存后,重新启动 MCP Server。

4.6 使用 MCP Inspector 完整测试​

现在,我们来模拟一天的健康记录。

测试日期统一使用:

2026-10-20

测试一:记录饮食​

调用上一章的 record_food:

{
"record_date": "2026-10-20",
"meal_type": "breakfast",
"food_name": "香蕉",
"calories": 105,
"weight_g": 118,
"protein_g": 1.3,
"carbs_g": 27,
"fat_g": 0.4
}

测试二:记录运动​

调用 record_exercise:

{
"record_date": "2026-10-20",
"exercise_type": "快走",
"duration_minutes": 40,
"calories_burned": 180
}

测试三:记录体重​

调用 record_weight:

{
"record_date": "2026-10-20",
"weight_kg": 85.1
}

测试四:记录断食​

调用 record_fasting:

{
"start_time": "2026-10-19T18:00:00+08:00",
"end_time": "2026-10-20T08:00:00+08:00"
}

测试五:查询当天健康记录​

最后调用:

get_daily_summary

传入:

{
"record_date": "2026-10-20"
}

如果之前没有其他同日记录,返回的核心数据应当类似:

{
"success": true,
"record_date": "2026-10-20",
"foods": [
{
"id": 1,
"record_date": "2026-10-20",
"meal_type": "breakfast",
"food_name": "香蕉",
"calories": 105,
"weight_g": 118,
"protein_g": 1.3,
"carbs_g": 27,
"fat_g": 0.4,
"notes": null
}
],
"exercises": [
{
"id": 1,
"record_date": "2026-10-20",
"exercise_type": "快走",
"duration_minutes": 40,
"calories_burned": 180,
"notes": null
}
],
"weights": [
{
"id": 1,
"record_date": "2026-10-20",
"weight_kg": 85.1,
"notes": null
}
],
"fastings": [
{
"id": 1,
"start_time": "2026-10-19T18:00:00+08:00",
"end_time": "2026-10-20T08:00:00+08:00",
"duration_hours": 14.0,
"notes": null
}
],
"summary": {
"food_count": 1,
"exercise_count": 1,
"total_calories": 105.0,
"total_exercise_calories": 180.0,
"food_calories_complete": true,
"exercise_calories_complete": true
}
}

这里的记录 ID 和具体返回数量取决于你的数据库中已有的数据。

如果重复执行测试,会生成多条记录。

这不是 MCP Inspector 的问题,而是因为我们目前的工具每次调用都会执行一次数据库插入。

4.7 重新理解 MCP 工具的设计​

现在,我们已经开发了多个 MCP 工具。

你可能注意到了一个特点:

每个工具都只负责完成一件明确的事情。

例如:

MCP 工具职责
record_food保存一条饮食记录
record_exercise保存一条运动记录
record_weight保存一条体重记录
record_fasting保存一条断食记录
get_daily_summary查询某一天的综合健康数据

为什么不设计一个万能工具:

save_health_data(...)

然后让它根据不同参数判断要保存什么?

当然可以,但这会让工具参数越来越复杂。

例如,记录体重不需要食物名称,记录断食不需要运动时长。

如果全部塞进一个函数,很多参数就只能设置为可选,工具描述也会变得不清晰。

而当我们接入 ChatGPT 后,模型需要根据工具名称、描述和参数 Schema 判断应该调用哪个工具。

因此,对于不同业务操作,使用职责清晰的工具通常更容易理解、测试和维护。

不过,也不是工具越多越好。

像 get_daily_summary 这样经常需要一起查询的数据,就可以设计成一个综合工具,减少客户端多次调用。

这也是我们在开发 MCP Server 时需要不断权衡的问题:工具应该足够明确,同时避免不必要的碎片化。

4.8 常见问题​

问题一:为什么新增数据表后,数据库里没有变化?​

首先确认 init_db() 中已经添加新的 CREATE TABLE 语句。

然后确认程序启动时执行了:

init_db()

如果数据库文件已经存在,SQLite 仍然可以创建新的表。

但要注意:CREATE TABLE IF NOT EXISTS 不会自动修改已经存在的表结构。

例如,后续想给 weight_records 增加一个称重时间字段,就需要使用数据库迁移或 ALTER TABLE 等方式。

问题二:为什么体重使用公斤,而不是斤?​

因为统一单位有利于后续统计。

在数据库中保存公斤,在用户界面中根据需要显示为斤即可。

例如:

weight_jin = weight_kg * 2

这比在数据库中混合保存公斤和斤更可靠。

问题三:为什么断食时间要包含时区?​

因为断食可能跨越日期,而且未来服务器可能部署在不同地区。

如果只保存:

2026-10-19 18:00

我们无法确定它属于哪个时区。

而:

2026-10-19T18:00:00+08:00

就明确表示这个时间使用 UTC+08:00。

这可以减少跨时区部署和统计时的歧义。

问题四:为什么每日汇总不直接计算减脂效果?​

因为减脂不能只通过一天的饮食热量和运动消耗判断。

我们目前的数据还不足以精确计算人体全天总能量消耗。

所以,系统应该先准确保存和汇总已知数据,再由 ChatGPT 结合用户的身体信息、活动情况和长期趋势进行分析,并明确说明估算的不确定性。

4.9 本章总结​

这一章,我们进一步完善了 Health Tracker。

在上一章饮食记录的基础上,新增了三张 SQLite 数据表:

  • exercise_records
  • weight_records
  • fasting_records

同时开发了四个 MCP 工具:

  • record_exercise
  • record_weight
  • record_fasting
  • get_daily_summary

现在,我们已经能够通过 MCP Inspector 保存和查询四种健康数据。

项目的整体结构仍然非常简单:

health-tracker-agent/
├── main.py
├── db.py
├── health.db
├── pyproject.toml
└── uv.lock

其中,main.py 负责 MCP 工具接口,db.py 负责 SQLite 数据操作。

到这里,我们已经完成了一套基础的健康数据管理服务。

但是,目前所有操作仍然需要手动填写参数。

例如,记录运动时,我们需要输入:

{
"record_date": "2026-10-20",
"exercise_type": "快走",
"duration_minutes": 40,
"calories_burned": 180
}

而我真正想要的是直接告诉 ChatGPT:

今天快走了 40 分钟,帮我估算消耗的热量并记录。

让 ChatGPT 自动理解我的意思,生成 MCP 工具参数,再调用我们编写的 Python 函数。

这正是下一章要解决的问题。

下一章预告:将本地 MCP Server 连接到 ChatGPT​

第五章,我们将开始使用 OpenAI Tunnel,将本地运行的 Health Tracker MCP Server 连接到 ChatGPT,并创建自己的私人插件。

届时,我们将第一次不通过 MCP Inspector,而是直接使用自然语言调用自己开发的工具。

从下一章开始,Health Tracker 将真正具备 AI 交互能力。

课程推荐

想系统学习这门课程?

你已阅读完本文。需要更完整的知识体系,可以继续了解「Vibe Coding 实战课程」。

查看课程详情